package top.ruanlitao.shortlink.project.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.codec.Hashids;
import cn.hutool.core.text.StrBuilder;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.ruanlitao.shortlink.project.common.constant.RedisKeyConstant;
import top.ruanlitao.shortlink.project.common.constant.ShortLinkConstant;
import top.ruanlitao.shortlink.project.common.convention.exception.ClientException;
import top.ruanlitao.shortlink.project.common.convention.exception.ServiceException;
import top.ruanlitao.shortlink.project.common.enums.VaildDateTypeEnum;
import top.ruanlitao.shortlink.project.config.GotoDomainWhiteListConfiguration;
import top.ruanlitao.shortlink.project.dao.entity.*;
import top.ruanlitao.shortlink.project.dao.mapper.*;
import top.ruanlitao.shortlink.project.dto.biz.ShortLinkStatsRecordDTO;
import top.ruanlitao.shortlink.project.dto.req.ShortLinkBatchCreateReqDTO;
import top.ruanlitao.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import top.ruanlitao.shortlink.project.dto.req.ShortLinkPageReqDTO;
import top.ruanlitao.shortlink.project.dto.req.ShortLinkUpdateReqDTO;
import top.ruanlitao.shortlink.project.dto.resp.*;
import top.ruanlitao.shortlink.project.mq.producer.ShortLinkStatsSaveProducer;
import top.ruanlitao.shortlink.project.service.ShortLinkService;
import top.ruanlitao.shortlink.project.service.idgen.IdGenerator;
import top.ruanlitao.shortlink.project.toolkit.HashUtil;
import top.ruanlitao.shortlink.project.toolkit.ShortLinkUtil;
import top.ruanlitao.shortlink.project.toolkit.WebsiteFetcherUtil;

import java.net.HttpURLConnection;
import java.net.URL;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
 * 短链接接口实现层
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {

    // 防止短链接创建时重复的布隆过滤器
    private final RBloomFilter<String> shortLinkCreateCachePenetrationBloomFilter;
    private final StringRedisTemplate stringRedisTemplate;
    private final RedissonClient redissonClient;

    // 当短链接生成重复时的最大重试次数
    private static final Integer MAX_RETRY_TIMES = 10;

    private final ShortLinkGotoMapper shortLinkGotoMapper;
    private final LinkAccessStatsMapper linkAccessStatsMapper;
    private final LinkLocaleStatsMapper linkLocaleStatsMapper;
    private final LinkOsStatsMapper linkOsStatsMapper;
    private final LinkBrowserStatsMapper linkBrowserStatsMapper;
    private final LinkDeviceStatsMapper linkDeviceStatsMapper;
    private final LinkNetworkStatsMapper linkNetworkStatsMapper;
    private final LinkAccessLogsMapper linkAccessLogsMapper;
    private final LinkStatsTodayMapper linkStatsTodayMapper;
    private final GotoDomainWhiteListConfiguration gotoDomainWhiteListConfiguration;
    private final ShortLinkStatsSaveProducer shortLinkStatsSaveProducer;

    // 唯一 Id 生成器
    private final IdGenerator idGenerator;
    private final Hashids hashids;

    // 高德地图 API key
    @Value("${short-link.stats.locale.amap-key}")
    String aMapKey;
    // 默认的短链接 domain
    @Value("${short-link.domain.default}")
    String createShortLinkDefaultDomain;
    // 重定向的地址
    @Value("${short-link.redirect.base.url}")
    String redirectBaseUrl;

    @Transactional(rollbackFor = Exception.class)
    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        // 验证白名单
        verificationWhitelist(requestParam.getOriginUrl());
        // 根据原链接生成一个短链接 uri
        // String shortLinkUri = generateShortLinkUri(requestParam);
        // 缓存发号 + 内存自增
        String shortLinkUri = generateShortLinkUri();
        // 拼接短链接 url
        String shortLinkUrl = StrBuilder.create(createShortLinkDefaultDomain)
                .append("/")
                .append(shortLinkUri)
                .toString();
        // 组装 ShortLinkDO 对象
        ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                .domain(createShortLinkDefaultDomain)
                .shortUri(shortLinkUri)
                .fullShortUrl(shortLinkUrl)
                .originUrl(requestParam.getOriginUrl())
                .gid(requestParam.getGid())
                .enableStatus(0)
                .createdType(requestParam.getCreatedType())
                .validDateType(requestParam.getValidDateType())
                .validDate(requestParam.getValidDate())
                .describe(requestParam.getDescribe())
                .totalPv(0)
                .totalUv(0)
                .totalUip(0)
                .delTime(0L)
                // .favicon(getFavicon(requestParam.getOriginUrl()))
                .favicon(WebsiteFetcherUtil.getFaviconUrl(requestParam.getOriginUrl()))
                .build();
        // 使用唯一索引兜底，多线程环境下可能会出现问题
        try {
            // 尝试插入短链接数据
            baseMapper.insert(shortLinkDO);
            // 将 gid 和 fullShortUrl 插入到 link_goto 表
            shortLinkGotoMapper.insert(
                    ShortLinkGotoDO.builder()
                            .gid(requestParam.getGid())
                            .fullShortUrl(shortLinkUrl)
                            .build());
        } catch (DuplicateKeyException ex) {
            // 唯一索引冲突
            // 进入到这里的，发生的情况是短链接在布隆过滤器中不存在，在数据库中已经存在（这种情况可能会在多线程场景下发生）
            // 此时，这个短连接已经被使用了
            // 所以，再次判断 shortLinkUrl 是否在布隆过滤器中，如果不存在，就加入
            // 这一逻辑主要是为了处理一个极端情况：当在 布隆过滤器 中添加短链接后，redis 突然宕机，这一添加操作恰好没有被持久化，那么这条短链接的数据就丢失了
            // 后面如果又生成了一条相同的短链接，通过布隆过滤器判断不存在，就插入到数据库中，插入数据库中会报唯一索引异常，那我们就看看布隆过滤器中有没有，没有就加入，防止后面出问题
            if (!shortLinkCreateCachePenetrationBloomFilter.contains(shortLinkUrl)) {
                shortLinkCreateCachePenetrationBloomFilter.add(shortLinkUrl);
            }
            // TODO 考虑是直接返回错误还是再次创建？
            throw new ServiceException(String.format("链接：%s 生成重复，请重试", requestParam.getOriginUrl()));
        }
        // 将短链接加入到布隆过滤器
        shortLinkCreateCachePenetrationBloomFilter.add(shortLinkUrl);
        // 缓存预热
        stringRedisTemplate.opsForValue().set(
                String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, shortLinkUrl),
                requestParam.getOriginUrl(),
                ShortLinkUtil.getLinkCacheExpireTime(requestParam.getValidDate()),
                TimeUnit.MILLISECONDS);
        // 返回数据
        return ShortLinkCreateRespDTO.builder()
                .gid(requestParam.getGid())
                .fullShortUrl("http://" + shortLinkUrl)
                .originUrl(requestParam.getOriginUrl())
                .build();
    }

    @Override
    public ShortLinkCreateRespDTO createShortLinkByLock(ShortLinkCreateReqDTO requestParam) {
        // 验证白名单
        verificationWhitelist(requestParam.getOriginUrl());
        String shortLinkUrl;
        // 获取分布式锁
        RLock lock = redissonClient.getLock(RedisKeyConstant.LOCK_SHORT_LINK_CREATE_KEY);
        lock.lock();
        try {
            // 根据原链接生成一个短链接 uri
            String shortLinkUri = generateShortLinkUriByLock(requestParam);
            // 拼接短链接 url
            shortLinkUrl = StrBuilder.create(createShortLinkDefaultDomain)
                    .append("/")
                    .append(shortLinkUri)
                    .toString();
            // 组装 ShortLinkDO 对象
            ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                    .domain(createShortLinkDefaultDomain)
                    .shortUri(shortLinkUri)
                    .fullShortUrl(shortLinkUrl)
                    .originUrl(requestParam.getOriginUrl())
                    .gid(requestParam.getGid())
                    .enableStatus(0)
                    .createdType(requestParam.getCreatedType())
                    .validDateType(requestParam.getValidDateType())
                    .validDate(requestParam.getValidDate())
                    .describe(requestParam.getDescribe())
                    .totalPv(0)
                    .totalUv(0)
                    .totalUip(0)
                    .delTime(0L)
                    .favicon(getFavicon(requestParam.getOriginUrl()))
                    .build();
            // 使用唯一索引兜底，多线程环境下可能会出现问题
            try {
                // 尝试插入短链接数据
                baseMapper.insert(shortLinkDO);
                // 将 gid 和 fullShortUrl 插入到 link_goto 表
                shortLinkGotoMapper.insert(
                        ShortLinkGotoDO.builder()
                                .gid(requestParam.getGid())
                                .fullShortUrl(shortLinkUrl)
                                .build());
            } catch (DuplicateKeyException ex) {
                // 唯一索引冲突
                throw new ServiceException(String.format("链接：%s 生成重复，请重试", requestParam.getOriginUrl()));
            }
            // 将短链接加入到布隆过滤器
            shortLinkCreateCachePenetrationBloomFilter.add(shortLinkUrl);
            // 缓存预热
            stringRedisTemplate.opsForValue().set(
                    String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, shortLinkUrl),
                    requestParam.getOriginUrl(),
                    ShortLinkUtil.getLinkCacheExpireTime(requestParam.getValidDate()),
                    TimeUnit.MILLISECONDS);
        } finally {
            lock.unlock();
        }
        // 返回数据
        return ShortLinkCreateRespDTO.builder()
                .gid(requestParam.getGid())
                .fullShortUrl("http://" + shortLinkUrl)
                .originUrl(requestParam.getOriginUrl())
                .build();
    }

    @Override
    public IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkPageReqDTO requestParam) {
        IPage<ShortLinkDO> resultPage = baseMapper.pageLink(requestParam);
        return resultPage.convert(each -> BeanUtil.toBean(each, ShortLinkPageRespDTO.class));
    }

    @Override
    public List<ShortLinkGroupCountQueryRespDTO> listGroupShortLinkCount(List<String> requestParam) {
        QueryWrapper<ShortLinkDO> queryWrapper = Wrappers.query(new ShortLinkDO())
                .select("gid as gid, count(*) as shortLinkCount")
                .in("gid", requestParam)
                .eq("enable_status", 0)
                .groupBy("gid");
        List<Map<String, Object>> maps = baseMapper.selectMaps(queryWrapper);
        return BeanUtil.copyToList(maps, ShortLinkGroupCountQueryRespDTO.class);
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public void updateShortLink(ShortLinkUpdateReqDTO requestParam) {
        // 验证白名单
        verificationWhitelist(requestParam.getOriginUrl());
        // 根据原 gid 和 fullShortLink 查询短链接是否存在
        LambdaQueryWrapper<ShortLinkDO> shortLinkExistQueryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                .eq(ShortLinkDO::getGid, requestParam.getOriginGid())
                .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                .eq(ShortLinkDO::getEnableStatus, 0);
        ShortLinkDO existShortLinkDO = baseMapper.selectOne(shortLinkExistQueryWrapper);
        if (existShortLinkDO == null) {
            throw new ClientException("短链接记录不存在");
        }
        // 程序运行到这里，标识原链接是存在的
        // 比较传递过来的原 gid 和现在的 gid 是否相同，如果相同，对数据做更新操作
        // 如果不同，需要将原记录先删除，再插入一条内容一致的新数据，这么做的原因是链接表的分片键是 gid，如果 gid 修改了，后面再查就不能定位到正确的表了
        if (Objects.equals(requestParam.getOriginGid(), requestParam.getGid())) {
            // 更新数据
            LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                    .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                    .eq(ShortLinkDO::getGid, requestParam.getGid())
                    .eq(ShortLinkDO::getEnableStatus, 0)
                    // 如果修改后有没有过期时间了，要将 validDate 更新为 null（这样写的原因当 type 为 0 时，传递过来的时间可能并为 null，因此需要单独判断下）
                    // 只能写在更新条件这里，不能写在 shortLinkDO 中
                    .set(Objects.equals(requestParam.getValidDateType(), VaildDateTypeEnum.PERMANENT.getType()), ShortLinkDO::getValidDate, null);
            ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                    .domain(existShortLinkDO.getDomain())
                    .shortUri(existShortLinkDO.getShortUri())
                    .favicon(Objects.equals(requestParam.getOriginUrl(), existShortLinkDO.getOriginUrl()) ? existShortLinkDO.getFavicon() : getFavicon(requestParam.getOriginUrl()))
                    .createdType(existShortLinkDO.getCreatedType())
                    .gid(requestParam.getGid())
                    .originUrl(requestParam.getOriginUrl())
                    .describe(requestParam.getDescribe())
                    .validDateType(requestParam.getValidDateType())
                    .validDate(requestParam.getValidDate())
                    .build();
            baseMapper.update(shortLinkDO, updateWrapper);
        } else {
            // TODO 通过消息队列异步处理修改缓存数据
            // 获取写锁
            RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(String.format(RedisKeyConstant.LOCK_GID_UPDATE_KEY, requestParam.getFullShortUrl()));
            RLock rLock = readWriteLock.writeLock();
            // 获取锁，如果获取不到锁，阻塞等待
            rLock.lock();
            try {
                // 删除数据
                LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
                        .eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
                        .eq(ShortLinkDO::getGid, existShortLinkDO.getGid())
                        .eq(ShortLinkDO::getEnableStatus, 0)
                        .eq(ShortLinkDO::getDelTime, 0L)
                        .set(ShortLinkDO::getDelFlag, 1);
                ShortLinkDO updateShortLinkDO = ShortLinkDO.builder()
                        .delTime(System.currentTimeMillis())
                        .build();
                baseMapper.update(updateShortLinkDO, updateWrapper);
                // 插入数据
                ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                        .domain(existShortLinkDO.getDomain())
                        .originUrl(requestParam.getOriginUrl())
                        .gid(requestParam.getGid())
                        .createdType(existShortLinkDO.getCreatedType())
                        .validDateType(requestParam.getValidDateType())
                        .validDate(requestParam.getValidDate())
                        .describe(requestParam.getDescribe())
                        .shortUri(existShortLinkDO.getShortUri())
                        .enableStatus(existShortLinkDO.getEnableStatus())
                        .totalPv(existShortLinkDO.getTotalPv())
                        .totalUv(existShortLinkDO.getTotalUv())
                        .totalUip(existShortLinkDO.getTotalUip())
                        .fullShortUrl(existShortLinkDO.getFullShortUrl())
                        .favicon(Objects.equals(requestParam.getOriginUrl(), existShortLinkDO.getOriginUrl()) ? existShortLinkDO.getFavicon() : getFavicon(requestParam.getOriginUrl()))
                        .delTime(0L)
                        .build();
                shortLinkDO.setCreateTime(existShortLinkDO.getCreateTime());
                baseMapper.insert(shortLinkDO);
                // 修改 goto 表中 短链接和 分组标识的对应
                LambdaUpdateWrapper<ShortLinkGotoDO> shortLinkGotoDOUpdateWrapper = Wrappers.lambdaUpdate(ShortLinkGotoDO.class)
                        .eq(ShortLinkGotoDO::getFullShortUrl, requestParam.getFullShortUrl());
                ShortLinkGotoDO shortLinkGotoDO = ShortLinkGotoDO.builder()
                        .fullShortUrl(requestParam.getFullShortUrl())
                        .gid(requestParam.getGid())
                        .build();
                shortLinkGotoMapper.update(shortLinkGotoDO, shortLinkGotoDOUpdateWrapper);
            } finally {
                rLock.unlock();
            }
        }

        // 处理缓存
        if (requestParam.getValidDateType() == VaildDateTypeEnum.CUSTOM.getType()
                && requestParam.getValidDate() != null
                && requestParam.getValidDate().isBefore(LocalDateTime.now())) {
            // 如果修改后的短链接是有有效期的并且已经过期了，直接删除访问缓存
            stringRedisTemplate.delete(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, existShortLinkDO.getFullShortUrl()));
        } else {
            // 如果修改后的短链接还没有过期或者是无限期
            // 删除空缓存
            stringRedisTemplate.delete(String.format(RedisKeyConstant.GOTO_NULL_VALUE_SHORT_LINK_KEY, existShortLinkDO.getFullShortUrl()));
            // 修改访问缓存数据，并设置过期时间
            LocalDateTime expireTime = Objects.equals(requestParam.getValidDateType(), VaildDateTypeEnum.CUSTOM.getType()) ? requestParam.getValidDate() : null;
            stringRedisTemplate.opsForValue().set(
                    String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, existShortLinkDO.getFullShortUrl()),
                    requestParam.getOriginUrl(),
                    ShortLinkUtil.getLinkCacheExpireTime(expireTime),
                    TimeUnit.MILLISECONDS);
        }
    }

    @SneakyThrows
    @Override
    public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
        // 拼接短链接完整路径（不包含 http://）
        String serverName = ((HttpServletRequest) request).getHeader("X-Forwarded-Host");
        if (serverName == null) {
            serverName = request.getServerName();
        }
        String fullShortUrl = serverName + "/" + shortUri;
        String redirectUrl = "http://" + redirectBaseUrl + "/page/notfound";
        // 第一步：查询缓存
        String originalUrl = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));
        if (StrUtil.isNotBlank(originalUrl)) {
            log.info("短链接 {} 命中缓存，直接跳转 {}", fullShortUrl, originalUrl);
            // 记录访问数据
            shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
            // 缓存中查到了重定向到原链接
            ((HttpServletResponse) response).sendRedirect(originalUrl);
            return;
        }
        // 第二步：通过布隆过滤器判断 fullShortUrl 是否存在（防止缓存穿透）
        // 如果不存在，直接返回到 404 页面
        if (!shortLinkCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
            log.info("短链接 {} 不存在于布隆过滤器，属于非法链接", fullShortUrl);
            // 返回到 404 页面
            ((HttpServletResponse) response).sendRedirect(redirectUrl);
            return;
        }
        // fullShortUrl 在布隆过滤器中存在，可能会存在误判
        // 第三步：在缓存中查找空值，如果找到，说明当前 fullShortUrl 是不存在数据库中，直接返回到 404 页面（也是防止缓存穿透）
        String gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_NULL_VALUE_SHORT_LINK_KEY, fullShortUrl));
        if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
            log.info("短链接 {} 不存在于数据库中或者已经过期了", fullShortUrl);
            // 返回到 404 页面
            ((HttpServletResponse) response).sendRedirect(redirectUrl);
            return;
        }
        // 第四步：通过分布式锁（防止缓存击穿）
        RLock lock = redissonClient.getLock(String.format(RedisKeyConstant.LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));
        // 获取锁
        lock.lock();
        try {
            // 第五步：再次查询缓存（双重判定锁）
            originalUrl = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));
            if (StrUtil.isNotBlank(originalUrl)) {
                // 缓存中查到了重定向到原链接
                // 记录访问数据
                shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
                ((HttpServletResponse) response).sendRedirect(originalUrl);
                return;
            }
            gotoIsNullShortLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_NULL_VALUE_SHORT_LINK_KEY, fullShortUrl));
            if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
                // 返回到 404 页面
                ((HttpServletResponse) response).sendRedirect(redirectUrl);
                return;
            }
            // 第六步：查询数据库
            // 在 goto 表通过 fullShortUrl 查询 gid
            log.info("短链接 {} 查询 goto 数据表获取短链接 gid", fullShortUrl);
            ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(
                    Wrappers.lambdaQuery(ShortLinkGotoDO.class)
                            .eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl)
            );
            if (shortLinkGotoDO == null) {
                // 将空值保存到缓存中
                stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_VALUE_SHORT_LINK_KEY, fullShortUrl), "-", 30L, TimeUnit.MINUTES);
                // 返回到 404 页面
                ((HttpServletResponse) response).sendRedirect(redirectUrl);
                return;
            }
            // 在 link 表查询短链接对应的原始链接（短链接一定要时启用状态才可以跳转）
            log.info("短链接 {} 查询 link 数据表获取短链接对应的原始链接", fullShortUrl);
            ShortLinkDO shortLinkDO = baseMapper.selectOne(
                    Wrappers.lambdaQuery(ShortLinkDO.class)
                            .eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
                            .eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
                            .eq(ShortLinkDO::getEnableStatus, 0)
            );
            // 如果这个短链接不存在（被删除了/被加入到回收组站了/已经过期了）
            if (shortLinkDO == null || shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().isBefore(LocalDateTime.now())) {
                // 将空值保存到缓存中
                stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_VALUE_SHORT_LINK_KEY, fullShortUrl), "-", 30L, TimeUnit.MINUTES);
                // 返回到 404 页面
                ((HttpServletResponse) response).sendRedirect(redirectUrl);
                return;
            }
            // 此时，短链接正常且在有效期内
            // 将原链接加入到缓存中，并根据短链接有效时间设置缓存过期时间
            log.info("短链接 {} 查询到原始链接 {}，准备跳转", fullShortUrl, shortLinkDO.getOriginUrl());
            stringRedisTemplate.opsForValue().set(
                    String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl),
                    shortLinkDO.getOriginUrl(),
                    ShortLinkUtil.getLinkCacheExpireTime(shortLinkDO.getValidDate()),
                    TimeUnit.MILLISECONDS);
            // 记录访问数据
            shortLinkStats(buildLinkStatsRecordAndSetUser(fullShortUrl, request, response));
            // 重定向到原链接
            ((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
        } finally {
            lock.unlock();
        }
    }

    @Override
    public ShortLinkBatchCreateRespDTO batchCreateShortLink(ShortLinkBatchCreateReqDTO requestParam) {
        List<String> originUrls = requestParam.getOriginUrls();
        List<String> describes = requestParam.getDescribes();
        List<ShortLinkBaseInfoRespDTO> results = new ArrayList<>();
        for (int i = 0; i < originUrls.size(); i++) {
            ShortLinkCreateReqDTO shortLinkCreateReqDTO = BeanUtil.toBean(requestParam, ShortLinkCreateReqDTO.class);
            shortLinkCreateReqDTO.setOriginUrl(originUrls.get(i));
            shortLinkCreateReqDTO.setDescribe(describes.get(i));
            try {
                ShortLinkCreateRespDTO shortLink = createShortLink(shortLinkCreateReqDTO);
                ShortLinkBaseInfoRespDTO shortLinkBaseInfoRespDTO = ShortLinkBaseInfoRespDTO.builder()
                        .describe(describes.get(i))
                        .originUrl(shortLink.getOriginUrl())
                        .fullShortUrl(shortLink.getFullShortUrl())
                        .build();
                results.add(shortLinkBaseInfoRespDTO);
            } catch (Throwable ex) {
                log.error("批量创建短链接失败，原始参数：{}", originUrls.get(i));
            }
        }
        return ShortLinkBatchCreateRespDTO.builder()
                .total(results.size())
                .baseLinkInfos(results)
                .build();
    }

    @Override
    public void shortLinkStats(ShortLinkStatsRecordDTO shortLinkStatsRecord) {
        // 组装数据
        Map<String, String> producerMap = new HashMap<>();
        producerMap.put("statsRecord", JSON.toJSONString(shortLinkStatsRecord));
        // 发送消息
        log.info("短链接 {} 的统计信息交由生产者", shortLinkStatsRecord.getFullShortUrl());
        shortLinkStatsSaveProducer.send(producerMap);
    }

    /**
     * 根据原链接生成短链接 uri
     *
     * @param requestParam 创建短链接请求参数
     * @return 唯一的短链接 uri
     */
    private String generateShortLinkUri(ShortLinkCreateReqDTO requestParam) {
        // 重试次数
        int retryTimes = 0;
        // 短链接 uri
        String shortLinkUri;
        // 原始链接
        String originUrl = requestParam.getOriginUrl();
        while (true) {
            // 尝试次数过多，抛出异常
            if (retryTimes > MAX_RETRY_TIMES) {
                throw new ServiceException("短链接频繁生成，请稍后再试");
            }
            // 优先使用原始链接生成短链接
            shortLinkUri = HashUtil.hashToBase62(originUrl);
            // 从布隆过滤器中判断短链接是否重复（判断是整个短链接 url）
            if (!shortLinkCreateCachePenetrationBloomFilter.contains(createShortLinkDefaultDomain + "/" + shortLinkUri)) {
                // 没有重复，直接跳出返回
                break;
            }
            // 重复了，次数 +1
            retryTimes++;
            // 加盐（用于继续重新生成）
            originUrl = requestParam.getOriginUrl() + UUID.randomUUID();
        }
        return shortLinkUri;
    }

    /**
     * 生成短链接 uri
     * @return 唯一的短链接 uri
     */
    private String generateShortLinkUri() {
        long nextId = idGenerator.getNextId();
        return hashids.encode(nextId);
    }

    /**
     * 根据原链接生成短链接 uri（使用分布式锁+查询数据库判断短链接是否重复）
     *
     * @param requestParam 创建短链接请求参数
     * @return 唯一的短链接 uri
     */
    private String generateShortLinkUriByLock(ShortLinkCreateReqDTO requestParam) {
        // 重试次数
        int retryTimes = 0;
        // 短链接 uri
        String shortLinkUri;
        // 原始链接
        String originUrl = requestParam.getOriginUrl();
        while (true) {
            // 尝试次数过多，抛出异常
            if (retryTimes > MAX_RETRY_TIMES) {
                throw new ServiceException("短链接频繁生成，请稍后再试");
            }
            // 优先使用原始链接生成短链接
            shortLinkUri = HashUtil.hashToBase62(originUrl);
            // 查询数据库判断这个短链接是否存在
            LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                    .eq(ShortLinkDO::getGid, requestParam.getGid())
                    .eq(ShortLinkDO::getFullShortUrl, createShortLinkDefaultDomain + "/" + shortLinkUri);
            ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
            if (shortLinkDO == null) {
                // 没有重复，直接跳出返回
                break;
            }
            // 重复了，次数 +1
            retryTimes++;
            // 加盐（用于继续重新生成）
            originUrl = requestParam.getOriginUrl() + UUID.randomUUID();
        }
        return shortLinkUri;
    }

    /**
     * 根据原 URL 获取其 icon 地址
     *
     * @param url 原地址
     * @return icon 地址
     */
    @SneakyThrows
    private String getFavicon(String url) {
        URL targetUrl = new URL(url);
        HttpURLConnection connection = (HttpURLConnection) targetUrl.openConnection();
        connection.setRequestMethod("GET");
        connection.connect();
        int responseCode = connection.getResponseCode();
        if (HttpURLConnection.HTTP_OK == responseCode) {
            try {
                Document document = Jsoup.connect(url).get();
                Element faviconLink = document.select("link[rel~=(?i)^(shortcut )?icon]").first();
                if (faviconLink != null) {
                    return faviconLink.attr("abs:href");
                }
            } catch (Throwable ex) {
                log.error("网址 {} 获取 favicon 失败，默认返回空字符串", url);
                return "";
            }
        }
        return null;
    }

    /**
     * 统计短链接监控数据并更新数据库（不使用了）
     *
     * @param fullShortUrl 短链接
     * @param request      请求对象
     * @param response     响应对象
     */
    @Deprecated
    private void shortLinkAccessStats(String fullShortUrl, ServletRequest request, ServletResponse response) {
        try {
            // 一、统计 UV
            // 判断当前用户是否是第一次请求该链接的标志
            AtomicBoolean uvFirstFlag = new AtomicBoolean();
            // 通过用户请求中携带的 Cookie 判断这个用户是不是同一个用户
            Cookie[] cookies = ((HttpServletRequest) request).getCookies();
            AtomicReference<String> uv = new AtomicReference<>();
            // 创建 Cookie 并将 Cookie 添加到响应中的任务
            Runnable addResponseCookieTask = () -> {
                // 使用 UUID 创建一个标识
                uv.set(UUID.randomUUID().toString());
                // 创建一个 Cookie
                Cookie uvCookie = new Cookie("uv-c", uv.get());
                // 设置 Cookie 过期时间（一个月）
                uvCookie.setMaxAge(60 * 60 * 24 * 30);
                // TODO 设置访问路径（我觉如果加上缓存了的话就没有必要）
                // uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.lastIndexOf("/"), fullShortUrl.length()));
                // 将 Cookie 设置到响应对象上
                ((HttpServletResponse) response).addCookie(uvCookie);
                // 将这个 Cookie 加入到当前短链接对应的 Set 集合中
                stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UV_STATS_SHORT_LINK_KEY, fullShortUrl), uv.get());
                // 设置用户是第一次访问这个链接
                uvFirstFlag.set(Boolean.TRUE);
            };
            if (ArrayUtil.isNotEmpty(cookies)) {
                // 如果携带的 Cookie 集合不为空，寻找我们需要的 Cookie
                Arrays.stream(cookies)
                        .filter(each -> Objects.equals("uv-c", each.getName()))
                        .findFirst()
                        .map(Cookie::getValue)
                        .ifPresentOrElse(
                                // 如果存在需要的 Cookie
                                each -> {
                                    uv.set(each);
                                    // 尝试将这个 Cookie 加入到以当前 fullShortUrl 为 Key 的 Set 缓存中，
                                    Long add = stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UV_STATS_SHORT_LINK_KEY, fullShortUrl), each);
                                    // 如果加入成功，表示该用户第一次访问该链接
                                    uvFirstFlag.set(add != null && add > 0L);
                                },
                                // 如果不存在需要的 Cookie，表示该用户第一次访问该链接，创建一个 Cookie 加入到响应中
                                addResponseCookieTask
                        );
            } else {
                // 如果携带的 Cookie 集合是空的，表示该用户第一次访问该链接，创建一个 Cookie 加入到响应中
                addResponseCookieTask.run();
            }

            // 二、统计 UIP
            // 获取当前用户的请求IP地址
            String actualIp = ShortLinkUtil.getActualIp((HttpServletRequest) request);
            // 尝试将这个 ip 加入到以当前 fullShortUrl 为 Key 的 Set 缓存中
            Long add = stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UIP_STATS_SHORT_LINK_KEY, fullShortUrl), actualIp);
            // 如果加入成功，表示该 IP 第一次访问该链接
            boolean uipFirstFlag = add != null && add > 0L;

            // 更新 PV UV UIP
            LocalDateTime now = LocalDateTime.now();
            LocalDate date = LocalDate.now();
            Integer weekday = now.getDayOfWeek().getValue();
            Integer hour = now.getHour();
            LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .pv(1)
                    .uv(uvFirstFlag.get() ? 1 : 0) // 根据 uvFirstFlag 来确定 uv 是 1 还是 0
                    .uip(uipFirstFlag ? 1 : 0) // 根据 uipFirstFlag 来确定 uip 是 1 还是 0
                    .hour(hour)
                    .weekday(weekday)
                    .build();
            linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);

            // 三、统计地区
            // 调用高德地图的API查询当前IP的地区
            Map<String, Object> paramMap = new HashMap<>();
            paramMap.put("key", aMapKey);
            paramMap.put("ip", actualIp);
            String responseJsonStr = HttpUtil.get(ShortLinkConstant.AMAP_REMOTE_URL, paramMap);
            JSONObject jsonObject = JSON.parseObject(responseJsonStr);
            String infocode = jsonObject.getString("infocode");
            String province = "";
            String city = "";
            String adcode = "";
            if (StrUtil.isNotBlank(infocode) && Objects.equals("10000", infocode)) {
                province = getValueOrDefault(jsonObject, "province");
                city = getValueOrDefault(jsonObject, "city");
                adcode = getValueOrDefault(jsonObject, "adcode");
                // 更新地区访问次数
                LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
                        .fullShortUrl(fullShortUrl)
                        .date(date)
                        .cnt(1)
                        .province(province)
                        .city(city)
                        .adcode(adcode)
                        .country("中国")
                        .build();
                linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
            }

            HttpServletRequest httpServletRequest = (HttpServletRequest) request;

            // 四、统计OS
            String os = ShortLinkUtil.getOs(httpServletRequest);
            LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .cnt(1)
                    .os(os)
                    .build();
            linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);

            // 五、统计浏览器
            String browser = ShortLinkUtil.getBrowser(httpServletRequest);
            LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .cnt(1)
                    .browser(browser)
                    .build();
            linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);

            // 六、统计设备
            String device = ShortLinkUtil.getDevice(httpServletRequest);
            LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .cnt(1)
                    .device(device)
                    .build();
            linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);

            // 七、统计网络
            String network = ShortLinkUtil.getNetwork(httpServletRequest);
            LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .cnt(1)
                    .network(network)
                    .build();
            linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);

            // 八、访问日志
            LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .user(uv.get())
                    .browser(browser)
                    .os(os)
                    .ip(actualIp)
                    .network(network)
                    .device(device)
                    .locale(StrUtil.join("-", "中国", province, city))
                    .build();
            linkAccessLogsMapper.insert(linkAccessLogsDO);

            // 九、记录 今日PV、UV、UIP
            LinkStatsTodayDO linkStatsTodayDO = LinkStatsTodayDO.builder()
                    .fullShortUrl(fullShortUrl)
                    .date(date)
                    .todayPv(1)
                    .todayUv(uvFirstFlag.get() ? 1 : 0)
                    .todayUip(uipFirstFlag ? 1 : 0)
                    .build();
            linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO);

            // 十、link 表记录累计的 PV、UV、UIP
            ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(Wrappers.lambdaQuery(ShortLinkGotoDO.class).eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl));
            String gid = shortLinkGotoDO.getGid();
            baseMapper.incrementStats(gid, fullShortUrl, 1, uvFirstFlag.get() ? 1 : 0, uipFirstFlag ? 1 : 0);
        } catch (Throwable ex) {
            log.error("短链接访问量统计异常", ex);
        }
    }

    /**
     * 构建短链接访问监控数据
     *
     * @param fullShortUrl 短链接
     * @param request      请求对象
     * @param response     响应对象
     * @return 监控数据对象
     */
    private ShortLinkStatsRecordDTO buildLinkStatsRecordAndSetUser(String fullShortUrl, ServletRequest request, ServletResponse response) {
        // 一、统计 UV
        // 判断当前用户是否是第一次请求该链接的标志
        AtomicBoolean uvFirstFlag = new AtomicBoolean();
        // 通过用户请求中携带的 Cookie 判断这个用户是不是同一个用户
        Cookie[] cookies = ((HttpServletRequest) request).getCookies();
        AtomicReference<String> uv = new AtomicReference<>();
        // 创建 Cookie 并将 Cookie 添加到响应中的任务
        Runnable addResponseCookieTask = () -> {
            // 使用 UUID 创建一个标识
            uv.set(UUID.randomUUID().toString());
            // 创建一个 Cookie
            Cookie uvCookie = new Cookie("uv-c", uv.get());
            // 设置 Cookie 过期时间（一个月）
            uvCookie.setMaxAge(60 * 60 * 24 * 30);
            // TODO 设置访问路径（我觉如果加上缓存了的话就没有必要）
            // uvCookie.setPath(StrUtil.sub(fullShortUrl, fullShortUrl.lastIndexOf("/"), fullShortUrl.length()));
            // 将 Cookie 设置到响应对象上
            ((HttpServletResponse) response).addCookie(uvCookie);
            // 将这个 Cookie 加入到当前短链接对应的 Set 集合中
            stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UV_STATS_SHORT_LINK_KEY, fullShortUrl), uv.get());
            // 设置用户是第一次访问这个链接
            uvFirstFlag.set(Boolean.TRUE);
        };
        if (ArrayUtil.isNotEmpty(cookies)) {
            // 如果携带的 Cookie 集合不为空，寻找我们需要的 Cookie
            Arrays.stream(cookies)
                    .filter(each -> Objects.equals("uv-c", each.getName()))
                    .findFirst()
                    .map(Cookie::getValue)
                    .ifPresentOrElse(
                            // 如果存在需要的 Cookie
                            each -> {
                                uv.set(each);
                                // 尝试将这个 Cookie 加入到以当前 fullShortUrl 为 Key 的 Set 缓存中，
                                Long add = stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UV_STATS_SHORT_LINK_KEY, fullShortUrl), each);
                                // 如果加入成功，表示该用户第一次访问该链接
                                uvFirstFlag.set(add != null && add > 0L);
                            },
                            // 如果不存在需要的 Cookie，表示该用户第一次访问该链接，创建一个 Cookie 加入到响应中
                            addResponseCookieTask
                    );
        } else {
            // 如果携带的 Cookie 集合是空的，表示该用户第一次访问该链接，创建一个 Cookie 加入到响应中
            addResponseCookieTask.run();
        }
        // 二、统计 UIP
        // 获取当前用户的请求IP地址
        String actualIp = ShortLinkUtil.getActualIp((HttpServletRequest) request);
        // 尝试将这个 ip 加入到以当前 fullShortUrl 为 Key 的 Set 缓存中
        Long add = stringRedisTemplate.opsForSet().add(String.format(RedisKeyConstant.UIP_STATS_SHORT_LINK_KEY, fullShortUrl), actualIp);
        // 如果加入成功，表示该 IP 第一次访问该链接
        boolean uipFirstFlag = add != null && add > 0L;

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        // 四、统计OS
        String os = ShortLinkUtil.getOs(httpServletRequest);
        // 五、统计浏览器
        String browser = ShortLinkUtil.getBrowser(httpServletRequest);
        // 六、统计设备
        String device = ShortLinkUtil.getDevice(httpServletRequest);
        // 七、统计网络
        String network = ShortLinkUtil.getNetwork(httpServletRequest);
        return ShortLinkStatsRecordDTO.builder()
                .fullShortUrl(fullShortUrl)
                .remoteAddr(actualIp)
                .os(os)
                .browser(browser)
                .device(device)
                .network(network)
                .uv(uv.get())
                .uvFirstFlag(uvFirstFlag.get())
                .uipFirstFlag(uipFirstFlag)
                .build();
    }

    private String getValueOrDefault(JSONObject jsonObject, String key) {
        String value = jsonObject.getString(key);
        return (StrUtil.isNotBlank(value) && Objects.equals("[]", value)) ? "未知" : value;
    }

    /**
     * 验证目标网站是否在我们的白名单中
     *
     * @param originUrl 目标网站
     */
    private void verificationWhitelist(String originUrl) {
        Boolean enable = gotoDomainWhiteListConfiguration.getEnable();
        if (enable == null || !enable) {
            return;
        }
        String domain = ShortLinkUtil.extractDomain(originUrl);
        if (StrUtil.isBlank(domain)) {
            throw new ClientException("跳转链接填写错误");
        }
        List<String> details = gotoDomainWhiteListConfiguration.getDetails();
        if (!details.contains(domain)) {
            throw new ClientException("演示环境为避免恶意攻击，请生成以下网站跳转链接：" + gotoDomainWhiteListConfiguration.getNames());
        }
    }
}
